Storage and Encryption

Use toolkit:kv-ksafe for key-value data and persistent secrets, data:database-delight for SQL storage, and toolkit:crypto for HPKE string encryption. Add the relevant module to the consumer's commonMain.dependencies; the app already includes these modules.

Store wrappers such as PlaygroundKvStore and DbSecretStore must use @SingleIn(AppScope::class) with the current implementation. Each wrapper instance calls factory.create(...), creating another KSafe instance. App-scoped wrappers ensure reuse; KvStore and SecretStore remain unscoped factory products.

Key-Value Storage

Inject KvStoreFactory and create a named store. Names must match [a-z][a-z0-9_]*; reuse the same name to reopen data. Values can be primitives or Kotlin-serializable types. Keep each key's type consistent.

kotlin
1import dev.zacsweers.metro.AppScope
2import dev.zacsweers.metro.Inject
3import dev.zacsweers.metro.SingleIn
4import io.baselines.sample.toolkit.kv.KvStore
5import io.baselines.sample.toolkit.kv.KvStoreFactory
6
7@Inject
8@SingleIn(AppScope::class)
9class SettingsStore(factory: KvStoreFactory) {
10 private val store = factory.create("settings", KvStore.Mode.Encrypted)
11
12 fun observeTheme() = store.observe<String>("theme")
13 suspend fun readTheme(): String? = store.read<String>("theme")
14 suspend fun saveTheme(theme: String) = store.put("theme", theme)
15 suspend fun removeTheme() = store.remove("theme")
16}

Use your project's namespace in place of io.baselines.sample.

KvStore.ModeProtection
PlainNo app-level encryption
EncryptedEncryption using platform-protected keys
HardwareIsolatedPrefers dedicated security hardware; falls back to Encrypted

The mode applies to subsequent writes. Changing it does not rewrite existing entries; reads use each entry's stored mode.

  • read<T>() returns null for missing entries, stored nulls, or values that cannot be decoded. Temporary decryption failures throw.
  • observe<T>() emits the current value and distinct changes, retrying temporary decryption failures.
  • contains() checks presence, including null or unreadable entries; it may block on initial load.
  • put(), remove(), and clear() suspend until persistence completes. Writing null does not remove an entry; clear() removes every entry in that named store.

Persistent Secrets and Database Encryption

Inject SecretStoreFactory and call create(name) to open an encrypted secret store, using the same store-name rules as KvStore. getOrCreate(key) accepts a nonblank key and returns its existing 32-byte secret, creating one only when absent. An unreadable stored secret throws instead of being replaced.

data:database-delight enables SQLCipher encryption through DatabaseModule.DB_ENCRYPTED, which defaults to true. DbSecretStore stores passphrases in db_secrets_v1, keyed by database name. The SQL drivers consume these secrets directly; they do not use HpkeCipher. DbSecretStore blocks while accessing secrets, so keep direct calls off the UI thread.

String Encryption

Inject HpkeCipher from io.baselines.sample.toolkit.hpke. Its suspend methods use Signum-backed HPKE:

kotlin
1// In a suspend caller with an injected HpkeCipher:
2val encrypted = hpkeCipher.encrypt("Example", HpkeKey.Playground)
3val decrypted = hpkeCipher.decrypt(encrypted, HpkeKey.Playground)

HpkeKey is in the same package. Add a separate entry with a stable alias for each independent encryption purpose. Encryption creates the selected recipient key if absent. Decryption requires the original key and never creates a replacement; losing it makes existing ciphertext unreadable.

Keys use platform storage with preferred hardware backing when key agreement is supported. Otherwise, the implementation persists a software key in an encrypted KSafe store. Hardware backing is not guaranteed. Treat ciphertext as opaque and retain it unchanged.

For existing apps, follow the storage migration notes.